[예외처리 #2] 예외 적용하기

▶ 왜 예외 처리(Exception Handling)가 중요할까?

백엔드 서버의 핵심 역할은 요청에 대해 적절한 응답을 반환하는 것
그 과정에서 발생할 수 있는 예외를 예측 가능하게 만들고, 사용자와 개발자 모두에게 의미 있는 정보를 주는 것이 중요

  • 사용자에게는 쉽고 명확한 메시지
  • 개발자에게는 디버깅 가능한 정보
  • 시스템에는 일관된 응답 구조

▶ 예외 처리의 기본 원칙

  • 예외의 책임을 명확히: 서비스 내부의 예외를 무조건 던지지 말 것
  • RuntimeException을 상속한 커스텀 예외로 관리
  • 컨트롤러 진입점에서 적절한 응답 포맷으로 반환
  • 에러 코드 + 메시지로 트래킹 용이하게 구성

▼ 통일된 예외 응답 포맷 설계

{
  "code": "ERROR_CODE",
  "message": "사용자 메시지",
  "data": "개발자 디버깅 메시지 or 유효성 에러 정보"
}

실전 예외 처리 구성

[Client] → [Controller] → [Service] → 예외 발생(throw)


                          [@RestControllerAdvice] (@ExceptionHandler로 catch)


                        통일된 ResponseDto(code, message, data) 생성


                                    [Client] 응답 반환

▷ **@RestControllerAdvice( 글로벌 예외 처리 )**

  • RestControllerAdvice를 이용하여 Controlle에서 발생하는 예외처리를 Catch하여 설계한 예외처리 방식대로 처리
  • @ExceptionHandler을 통해 예외 Catch
  • ResponseEntity에 ResponseDto라는 통일된 응답용 Dto를 이용하여 CODE에 따라 메시지 응답 전달
  • 즉, 결국 Contrller에서 응답코드를 보내므로 Client 도달하기전에 중간에 위치한다면 통일된 규격으로 예외처리가 가능하다
@RestControllerAdvice
@Slf4j
public class CustomExceptionHandler {

    @ExceptionHandler(IOException.class)
    public ResponseEntity<?> handleIOException(IOException ex) {
        log.warn("IOException : {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ResponseDto<>(Constant.ERROR_CODE, "입출력 오류 발생", ex.getMessage()));
    }

    @ExceptionHandler({NullPointerException.class, NoSuchElementException.class})
    public ResponseEntity<?> handleResourceNotFound(RuntimeException ex) {
        log.warn("리소스 없음 : {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ResponseDto<>(Constant.ERROR_CODE, "리소스를 찾을 수 없음", ex.getMessage()));
    }

    @ExceptionHandler(CustomApiException.class)
    public ResponseEntity<?> handleCustomApiException(CustomApiException ex) {
        log.warn("잘못된 요청 : {}", ex.getMessage());
        return ResponseEntity.badRequest()
            .body(new ResponseDto<>(Constant.ERROR_CODE, "잘못된 요청", ex.getMessage()));
    }

    //...
    }
}

→ 반영된 예외처리 결과

{
    "code": -1,
    "msg": "refresh token expired",
    "data": ""
}

▷ 추가) AOP를 활용한 검증 예외 처리

왜 AOP를 사용하는가?

  • 컨트롤러마다 반복되는 BindingResult 체크 제거
  • 코드 간결화 & 일관된 예외 처리
@Aspect
@Component
@Slf4j
public class RequestBodyValidatorAspect {

    @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
    public void postMapping() {}

    @Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
    public void putMapping() {}

    @Around("postMapping() || putMapping()")
    public Object validateRequestBody(ProceedingJoinPoint joinPoint) throws Throwable {
        for (Object arg : joinPoint.getArgs()) {
            if (arg instanceof BindingResult bindingResult && bindingResult.hasErrors()) {
                Map<String, String> errorMap = new HashMap<>();
                for (FieldError error : bindingResult.getFieldErrors()) {
                    log.warn("검증 실패 : {} - {}", error.getField(), error.getDefaultMessage());
                    errorMap.put(error.getField(), error.getDefaultMessage());
                }
                throw new CustomValidException("유효성 검사 실패", errorMap);
            }
        }
        return joinPoint.proceed();
    }
}

▷ 커스텀 예외 클래스 예시

public class CustomApiException extends RuntimeException {
    public CustomApiException(String message) {
        super(message);
    }
}

public class CustomValidException extends RuntimeException {
    private final Map<String, String> errorMap;

    public CustomValidException(String message, Map<String, String> errorMap) {
        super(message);
        this.errorMap = errorMap;
    }

    public Map<String, String> getErrorMap() {
        return errorMap;
    }
}

관련 문서